import libtcodpy as libtcod

import objects
import character
import player
from utils import Directions, no_render_repr, get_tile_render_repr_fast
import tiles


# Layers of one map level: () = not sure if it is going to be added
# Floor = type of floor, for example no floor would mean a hole
# Stuff = traps, grass, levers
# Items = items dropped on the floor
# (Liquids) = maybe I will implement liquids later, but I am not entirely sure
#   probably there will be one floor-layer of liquids and one wall-layer
# (Gases) = toxic gases, flammable gases etc.
# Wall = walls, doors
# Characters = player character, NPCs, mobs, everything that moves
# (Magic) = reserved for special magical effects (like lightning for example)


class TileFullError (Exception):
    pass


class NotFoundError(Exception):
    pass


class AmbiguityError(Exception):
    pass


class TileLayer:
    def __init__(self, allowed_classes, more_than_one=True, allow_all=False):
        self.allowed_classes = allowed_classes
        self.more_than_one = more_than_one
        self.allow_all = allow_all
        self.objects = set()

    def add(self, obj):
        """Add an object to this layer."""
        enough_space = len(self.objects) == 0 or self.more_than_one
        correct_type = self.allow_all or self.is_object_allowed(obj)
        if not correct_type:
            raise TypeError(
                "Object of this type can't be put here.")
        if not enough_space:
            raise TileFullError("Only one object can fit here.")
        self.objects.add(obj)

    def remove(self, obj):
        """Remove an object from this layer."""
        if obj not in self:
            raise NotFoundError(
                "Object is not contained in this layer.")
        self.objects.remove(obj)

    def can_add(self, obj):
        """Check if an object can be added."""
        enough_space = len(self.objects) == 0 or not self.more_than_one
        correct_type = self.allow_all or self.is_object_allowed(obj)
        return enough_space and correct_type

    def __contains__(self, obj):
        return obj in self.objects

    def is_class_allowed(self, class_):
        """Check if class is accepted in this layer."""
        return issubclass(class_, tuple(self.allowed_classes))

    def is_object_allowed(self, obj):
        """Check if object obj is accepted in this layer."""
        return isinstance(obj, tuple(self.allowed_classes))

    def __iter__(self):
        """Iterator on objects."""
        return iter(self.objects)

    def __len__(self):
        """Number of objects in this layer."""
        return len(self.objects)

    def is_empty(self):
        return not bool(self.objects)

    def get_some(self):
        """Return some object from this layer."""
        try:
            return next(iter(self))
        except StopIteration:
            return None

    @property
    def obj(self):
        """Return object from this layer if only one can be contained."""
        if not self.more_than_one:
            return self.get_some()
        else:
            raise AmbiguityError("Can contain more objects.")


class Tile:
    # TODO: Add light information, whether the tile is visible now, last seen
    # tile and whether the tile is discovered
    # TODO: Maybe make this all effective by removing TileLayer class and
    # stuff.
    layer_render_priority = [
        "characters", "wall", "items", "stuff", "floor"]

    def __init__(self, level, x, y):
        self.level = level
        self.changed = True
        self.layers = {
            "floor": TileLayer({objects.Floor}, False),
            "stuff": TileLayer({objects.Stuff}, allow_all=True),
            "items": TileLayer({objects.Item}),
            "wall": TileLayer({objects.Wall}, False),
            "characters": TileLayer({character.Character}, False)
        }
        self.x = x
        self.y = y
        self.render_recompute = True
        self.render_repr = no_render_repr

    def __getitem__(self, layer_name):
        """Get layer with name layer_name from this tile."""
        if layer_name in self.layers:
            return self.layers[layer_name]
        else:
            raise ValueError("Wrong layer id.")

    def get_correct_layer(self, obj):
        """Try to match layer for object."""
        general_matches = []
        specific_matches = []
        for name, layer in self.layers.items():
            if layer.can_add(obj):
                if layer.allow_all:
                    general_matches.append(name)
                else:
                    specific_matches.append(name)
        if not specific_matches and not general_matches:
            raise TypeError(
                "Object of this type doesn't fit anywhere.")
        specific_not_sure = len(specific_matches) > 1
        general_not_sure = not specific_matches and len(general_matches) > 1
        if specific_not_sure or general_not_sure:
            raise AmbiguityError("More matching layers are available.")
        if specific_matches:
            return specific_matches[0]
        else:
            return general_matches[0]

    def add(self, obj, layer_name):
        """Add an object obj to this tile to layer with name layer_name."""
        self.layers[layer_name].add(obj)
        self.on_change()
        if layer_name == "wall":
            self.on_wall_change()

    def try_add(self, obj):
        """Try to match layer for that object and add it."""
        correct_layer = self.get_correct_layer(obj)
        # TODO: add call to object hooks
        self.add(obj, correct_layer)

    def remove(self, obj):
        """Try to remove object from this tile."""
        for name, layer in self.layers.items():
            if obj in layer:
                # TODO: add call to object hooks
                layer.remove(obj)
                self.on_change()
                if name == "wall":
                    self.on_wall_change()
                return
        raise NotFoundError("Object isn't in this tile.")

    def disable_render_recompute(self):
        self.render_recompute = False

    def enable_render_recompute(self):
        self.render_recompute = True

    def on_change(self):
        """Should be called each time this tile gets changed somehow."""
        self.changed = True
        self.level.changed = True
        if self.render_recompute:
            self.render_repr = get_tile_render_repr_fast(self)

    def on_wall_change(self):
        new_transparent = not (
            len(self["wall"]) > 0 and not self["wall"].obj.is_transparent)
        if self.is_transparent == new_transparent:
            return
        libtcod.map_set_properties(
            self.level.tcod_map, self.x, self.y, new_transparent, True)
        self.level.los_changed = True

    def all_call(self, function_name, *arguments):
        for obj in self.all_objects:
            try:
                type(obj).__dict__[function_name](obj, *arguments)
            except KeyError:
                pass

    @property
    def all_objects(self):
        """Return a set of all objects in this tile."""
        all_objects = set()
        for name, layer in self.layers.items():
            all_objects.update(layer.objects)
        return all_objects

    @property
    def around_tiles(self):
        """Return list of Moore neighboors of this tile."""
        return [self.level.get(self.x + dx, self.y + dy) for dx, dy in Directions.coords]

    @property
    def connected_tiles(self):
        """Return set of Moore neighboors of this tile."""
        connected = {tile for tile in self.around_tiles if tile is not None}

    @property
    def is_transparent(self):
        return libtcod.map_is_transparent(self.level.tcod_map, self.x, self.y)

    def __contains__(self, obj):
        for name, layer in self.layers.items():
            if obj in layer:
                return True
        return False


class MapLevel:
    def __init__(self, width, height, depth, whole_map):
        self.width = width
        self.height = height
        self.depth = depth
        self.whole_map = whole_map
        self.rnd = whole_map.rnd
        self.changed = True
        self.los_changed = True

        self.is_active = False
        self.tiles = [[Tile(self, x, y) for y in range(height)]
                      for x in range(width)]
        self.tcod_map = libtcod.map_new(width, height)
        self.old_tcod_map = libtcod.map_new(width, height)

    def get(self, x, y):
        """Get tile at position x, y."""
        try:
            return self.tiles[x][y]
        except IndexError:
            return None

    def move(self, obj, src_pos, dest_pos, dest_layer=None):
        """Move object from this layer from one tile to another tile.

        obj - object to be moved
        src_pos - either a (x, y) tuple or a Tile where is the object
        dest_pos - either a (x, y) tuple or a Tile where do you want an object()
        dest_layer - layer where to put this object (if None, I will try to guess)
        """
        # TODO: maybe add call to object hooks ?
        if isinstance(src_pos, tuple):
            src_pos = self.get(*src_pos)
        if isinstance(dest_pos, tuple):
            dest_pos = self.get(*dest_pos)
        src_pos.remove(obj)
        if dest_layer is None:
            dest_pos.try_add(obj)
        else:
            dest_pos.add(obj, dest_layer)

    def generate(self):
        """Generate this level."""
        # TODO: Maybe make "static" objects, so that there will be just one
        # instance in map
        # OR make object that encapsulates other objects and creates more
        # instance only on change
        ratio_wall = 0.2
        ratio_boulder = 0.02
        ratio_monster = 0.002
        ratio_teleporter = 0.005
        ratio_boots = 0.005
        for x in range(self.width):
            for y in range(self.height):
                tile = self.get(x, y)
                tile.disable_render_recompute()
                if self.rnd.random() < ratio_teleporter:
                    teleporter = tiles.Teleporter()
                    teleporter.place_on_map(tile)
                else:
                    floor = tiles.NormalFloor()
                    floor.place_on_map(tile)
                is_boundary = x == 0 or y == 0 or \
                    x == self.width - 1 or y == self.height - 1
                if self.rnd.random() < ratio_wall or is_boundary:
                    wall = tiles.NormalWall()
                    wall.place_on_map(tile)
                elif self.rnd.random() < ratio_boulder:
                    boulder = tiles.Boulder()
                    boulder.place_on_map(tile)
                elif self.rnd.random() < ratio_boulder:
                    boulder = tiles.RollingBoulder()
                    boulder.place_on_map(tile)
                elif self.rnd.random() < ratio_boulder:
                    boulder = tiles.BouncyBoulder()
                    boulder.place_on_map(tile)
                elif self.rnd.random() < ratio_monster:
                    monster = character.Monster()
                    monster.place_on_map(tile)
                if self.rnd.random() < ratio_boots:
                    boots = tiles.BootsOfSpeed()
                    boots.place_on_map(tile)
        for x in range(self.width):
            for y in range(self.height):
                tile = self.get(x, y)
                tile.enable_render_recompute()
                tile.on_change()
                tile.on_wall_change()

    def deactivate(self):
        """Mark this level as inactive (player is not here)."""
        self.is_active = False

    def activate(self):
        """Mark this level as active (player is here)."""
        self.is_active = True

    def random_tile(self):
        return self.get(self.rnd.randint(0, self.width - 1), self.rnd.randint(0, self.height - 1))

    @property
    def next_layer(self):
        """Return layer under this one."""
        if self.depth == len(self.whole_map.layers) - 1:
            return None
        return self.whole_map.layers[self.depth + 1]

    @property
    def prev_layer(self):
        """Return layer above this one."""
        if self.depth == 0:
            return None
        return self.whole_map.layers[self.depth - 1]


class Map:
    def __init__(self, width, height, rnd, game):
        self.width = width
        self.height = height
        self.game = game
        self.rnd = rnd

        self.layers = []
        self.active_layer = None
        self.layers.append(MapLevel(width, height, 0, self))
        self.active_layer = self.layers[0]
        self.active_layer.activate()
        self.active_layer.generate()

        self.player = player.Player()
        tile = self.active_layer.get(
            rnd.randint(0, self.width - 1),
            rnd.randint(0, self.height - 1))
        while not self.player.can_enter(tile):
            tile = self.active_layer.get(
                rnd.randint(0, self.width - 1),
                rnd.randint(0, self.height - 1))
        self.player.place_on_map(tile)

    def move(self, obj, src_level, src_pos,
             dest_level, dest_pos, dest_layer=None):
        """Move object from one tile to another between levels.

        obj - object to be moved
        src_level - either a depth or a MapLevel from where do you want to move
        src_pos- either a (x, y) tuple or a Tile where is the object
        dest_level - either a depth or a MapLevel to where do you want to move
        dest_pos - either a (x, y) tuple or a Tile where do you want an object()
        dest_layer - layer where to put this object (if None, I will try to guess)
        """
        if isinstance(src_level, int):
            src_level = self.layers[src_level]
        if isinstance(dest_level, int):
            dest_level = self.layers[dest_level]
        if isinstance(dest_pos, tuple):
            dest_pos = dest_level.get(*dest_pos)
        src_level.move(obj, src_pos, dest_pos, dest_layer)

    def activate_layer(self, layer):
        """Selects new layer as active."""
        self.active_layer.deactivate()
        self.active_layer = layer
        self.active_layer.activate()
